<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AI 对话</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    ::-webkit-scrollbar { width: 8px; height: 8px; }
    ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
    ::-webkit-scrollbar-track { background: #f1f5f9; }
  </style>
</head>
<body class="bg-slate-50 text-slate-800">
  <div class="h-screen flex">
    <aside class="w-80 border-r border-slate-200 bg-white flex flex-col">
      <div class="p-4 border-b border-slate-200">
        <div class="flex items-center gap-2">
          <button id="btnNewChat" class="px-3 py-2 rounded bg-blue-600 text-white hover:bg-blue-700">新建聊天</button>
          <button id="btnToggleList" class="px-3 py-2 rounded bg-slate-100 hover:bg-slate-200">展开/收起</button>
        </div>
        <div class="mt-3">
          <label class="block text-sm mb-1" for="kbSelect">知识库</label>
          <select id="kbSelect" class="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:ring-blue-200">
            <option value="">不使用知识库</option>
          </select>
        </div>
        <div class="mt-3">
          <label class="block text-sm mb-1" for="modelInput">模型</label>
          <input id="modelInput" type="text" value="deepseek-r1:1.5b" class="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:ring-blue-200" />
        </div>
      </div>
      <div id="chatList" class="flex-1 overflow-y-auto"></div>
    </aside>

    <main class="flex-1 flex flex-col">
      <div class="p-4 border-b border-slate-200 bg-white flex items-center justify-between">
        <div class="flex items-center gap-3">
          <div class="select-none">
            <span class="text-2xl font-extrabold bg-gradient-to-r from-green-500 to-teal-600 text-transparent bg-clip-text">chillGarden</span>
            <span class="text-xs text-slate-400 ml-1">AI</span>
          </div>
          <div>
          <h1 id="currentChatTitle" class="text-lg font-semibold">未选择聊天</h1>
          <p class="text-xs text-slate-500">提示：Ctrl+Enter 发送</p>
          <div id="streamStatus" class="text-xs text-slate-500"></div>
          </div>
        </div>
        <div class="flex items-center gap-2">
          <button id="btnRenameChat" class="px-3 py-2 rounded bg-slate-100 hover:bg-slate-200" disabled>重命名</button>
          <button id="btnDeleteChat" class="px-3 py-2 rounded bg-red-50 text-red-600 hover:bg-red-100" disabled>删除</button>
        </div>
      </div>

      <div id="messageList" class="flex-1 overflow-y-auto p-6 space-y-4 bg-slate-50"></div>

      <div class="p-4 border-t border-slate-200 bg-white">
        <div class="flex gap-3 items-start">
          <div class="flex-1">
            <label class="block text-sm mb-1" for="messageInput">内容</label>
            <textarea id="messageInput" rows="3" class="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:ring-blue-200" placeholder="输入对话内容，Ctrl+Enter 发送"></textarea>
          </div>
          <div class="flex-none flex flex-col gap-2">
            <button id="btnSend" class="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700">发送</button>
            <button id="btnStop" class="px-4 py-2 rounded bg-rose-600 text-white hover:bg-rose-700" disabled>停止</button>
          </div>
        </div>
      </div>
    </main>
  </div>

  <script>
    const BASE_URL = window.location.origin; // 使用当前后端域名
    const API_RAG_TAGS = BASE_URL + '/api/v1/rag/query_rag_tag_list';
    const API_STREAM_GET = BASE_URL + '/api/v1/ollama/generate_stream';
    const API_STREAM_RAG_GET = BASE_URL + '/api/v1/ollama/generate_stream_rag';
    const API_STREAM_RAG_POST = BASE_URL + '/api/v1/ollama/generate_stream_rag';

    const state = { chats: [], currentId: null, expanded: true };
    let currentStream = { type:null, es:null, abortController:null };
    // 有状态 <think> 过滤标记：当进入 <think> 后，直到遇到 </think> 为止都丢弃
    let inThinkBlock = false;
    function loadState(){ try{ const raw=localStorage.getItem('ai_chats'); if(raw){ const data=JSON.parse(raw); state.chats=Array.isArray(data.chats)?data.chats:[]; state.currentId=data.currentId||null; } }catch(e){} }
    function saveState(){ localStorage.setItem('ai_chats', JSON.stringify({ chats: state.chats, currentId: state.currentId })); }
    function uid(){ return 'c_'+Math.random().toString(36).slice(2); }
    function nowTitle(){ return '新的对话 '+new Date().toLocaleTimeString(); }
    function currentChat(){ return state.chats.find(c=>c.id===state.currentId)||null; }
    function escapeHtml(str){ return (str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
    // 统一追加并清洗：移除 <think>... 块与 RAG 首事件提示
    function appendSanitized(aiMsg, chunk){
      try{
        let text = chunk || '';
        // 先做按块清洗：基于状态机处理 <think> 开始与结束，避免只出现开标签时泄露内容
        if(inThinkBlock){
          const endIdx = text.search(/<\s*\/\s*think\s*>/i);
          if(endIdx >= 0){
            // 丢弃到结束标签之前的内容，并清掉结束标签
            text = text.slice(endIdx).replace(/^<\s*\/\s*think\s*>/i,'');
            inThinkBlock = false;
          }else{
            // 当前整块都在 think 中，直接丢弃
            text = '';
          }
        }
        // 若未在 think 中，检查是否出现新的 <think>
        const startIdx = text.search(/<\s*think\b/i);
        if(startIdx >= 0){
          const endIdx2 = text.search(/<\s*\/\s*think\s*>/i);
          if(endIdx2 >= 0 && endIdx2 > startIdx){
            // 同一块中出现开和闭，移除中间段
            const before = text.slice(0, startIdx);
            const after = text.slice(endIdx2).replace(/^<\s*\/\s*think\s*>/i,'');
            text = before + after;
          }else{
            // 只有开没有闭，进入 think 状态，并丢弃从开标签到末尾
            text = text.slice(0, startIdx);
            inThinkBlock = true;
          }
        }

        // 移除以【RAG】开头的提示行（含行尾换行）
        text = text.replace(/^【RAG】[^\n]*\n?/gm, '');

        // 追加本次块
        aiMsg.content += text;

        // 再做一次兜底的全量清洗以防遗漏
        aiMsg.content = aiMsg.content.replace(/<\s*think[\s\S]*?<\s*\/\s*think\s*>/gi, '');
        aiMsg.content = aiMsg.content.replace(/^【RAG】[^\n]*\n?/gm, '');
      }catch(e){ /* 忽略清洗异常 */ }
    }

    function renderChatList(){ const list=document.getElementById('chatList'); list.innerHTML=''; const c=document.createElement('div'); c.className=state.expanded?'divide-y divide-slate-200':'hidden'; state.chats.forEach(chat=>{ const item=document.createElement('div'); const active=chat.id===state.currentId; item.className='p-3 cursor-pointer hover:bg-slate-50 '+(active?'bg-slate-100':''); item.innerHTML=`<div class="flex items-center justify-between"><div class="font-medium truncate">${chat.title}</div><div class="text-xs text-slate-500">${chat.messages?.length||0} 条</div></div>`; item.addEventListener('click',()=>{ state.currentId=chat.id; saveState(); renderAll(); }); c.appendChild(item); }); list.appendChild(c); }
    function renderCurrentChatHeader(){ const titleEl=document.getElementById('currentChatTitle'); const btnRename=document.getElementById('btnRenameChat'); const btnDelete=document.getElementById('btnDeleteChat'); const chat=currentChat(); if(!chat){ titleEl.textContent='未选择聊天'; btnRename.disabled=true; btnDelete.disabled=true; return; } titleEl.textContent=chat.title; btnRename.disabled=false; btnDelete.disabled=false; }
    function renderMessages(){ const box=document.getElementById('messageList'); box.innerHTML=''; const chat=currentChat(); if(!chat){ const empty=document.createElement('div'); empty.className='text-slate-500 text-sm'; empty.textContent='请选择左侧一个聊天或创建新聊天'; box.appendChild(empty); return; } chat.messages.forEach(msg=>{ const bubble=document.createElement('div'); const isUser=msg.role==='user'; bubble.className='max-w-3xl px-4 py-3 rounded shadow-sm '+(isUser?'bg-blue-50 border border-blue-100':'bg-slate-50 border border-slate-200'); bubble.innerHTML=`<div class="text-xs ${isUser?'text-blue-600':'text-slate-600'} mb-1">${isUser?'我':'AI'}</div><div class="whitespace-pre-wrap leading-relaxed">${escapeHtml(msg.content||'')}</div>`; box.appendChild(bubble); }); box.scrollTop=box.scrollHeight; }
    function renderAll(){ renderChatList(); renderCurrentChatHeader(); renderMessages(); }

    document.getElementById('btnNewChat').addEventListener('click',()=>{ const id=uid(); const chat={ id, title: nowTitle(), messages: [] }; state.chats.unshift(chat); state.currentId=id; saveState(); renderAll(); });
    document.getElementById('btnToggleList').addEventListener('click',()=>{ state.expanded=!state.expanded; renderChatList(); });
    document.getElementById('btnRenameChat').addEventListener('click',()=>{ const chat=currentChat(); if(!chat) return; const name=prompt('请输入新的聊天名称', chat.title); if(name&&name.trim()){ chat.title=name.trim(); saveState(); renderAll(); } });
    document.getElementById('btnDeleteChat').addEventListener('click',()=>{ const chat=currentChat(); if(!chat) return; if(confirm('确认删除当前聊天？')){ state.chats=state.chats.filter(c=>c.id!==chat.id); if(state.currentId===chat.id) state.currentId=null; saveState(); renderAll(); } });

    document.getElementById('btnSend').addEventListener('click', sendMessage);
    document.getElementById('btnStop').addEventListener('click', stopCurrentStream);
    document.getElementById('messageInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter'&&e.ctrlKey){ e.preventDefault(); sendMessage(); } });
    function ensureChat(){ if(!currentChat()){ const id=uid(); const chat={ id, title: nowTitle(), messages: [] }; state.chats.unshift(chat); state.currentId=id; } }
    function sendMessage(){ ensureChat(); const chat=currentChat(); const model=document.getElementById('modelInput').value.trim(); const message=document.getElementById('messageInput').value.trim(); const ragTag=document.getElementById('kbSelect').value.trim(); if(!message) return; chat.messages.push({ role: 'user', content: message }); const aiMsg={ role:'assistant', content:'' }; chat.messages.push(aiMsg); saveState(); renderAll(); const statusEl=document.getElementById('streamStatus'); statusEl.textContent = ragTag ? 'RAG 流式连接中…' : '流式连接中…'; document.getElementById('btnStop').disabled=false; if(ragTag){ streamWithEventSourceRag(model, ragTag, message, aiMsg); } else { streamWithEventSource(model, message, aiMsg); } document.getElementById('messageInput').value=''; }

    function stopCurrentStream(){ const statusEl=document.getElementById('streamStatus'); try{ if(currentStream.type==='sse' || currentStream.type==='sse-rag'){ if(currentStream.es){ currentStream.es.close(); } } else if(currentStream.type==='post-rag'){ if(currentStream.abortController){ currentStream.abortController.abort(); } } } finally { currentStream = { type:null, es:null, abortController:null }; document.getElementById('btnStop').disabled=true; statusEl.textContent='已停止'; } }

    function streamWithEventSource(model, message, aiMsg){ const url=new URL(API_STREAM_GET); url.searchParams.set('model', model); url.searchParams.set('message', message); const es=new EventSource(url.toString()); currentStream = { type:'sse', es, abortController:null }; let closed=false; let lastUpdate=Date.now(); let hasContent=false; let retried=false; const statusEl=document.getElementById('streamStatus'); statusEl.textContent='流式连接中…'; const watchdog=setInterval(()=>{ if(Date.now()-lastUpdate>15000 && !closed){ es.close(); closed=true; clearInterval(watchdog); statusEl.textContent='已完成'; document.getElementById('btnStop').disabled=true; currentStream={ type:null, es:null, abortController:null }; } }, 1000); es.onmessage=(event)=>{ try{ const data=JSON.parse(event.data); const content=extractContent(data); const finished=isFinished(data); if(content){ hasContent=true; appendSanitized(aiMsg, content); saveState(); renderMessages(); lastUpdate=Date.now(); } if(finished){ es.close(); closed=true; clearInterval(watchdog); statusEl.textContent='已完成'; document.getElementById('btnStop').disabled=true; currentStream={ type:null, es:null, abortController:null }; } }catch(e){ console.warn('SSE 数据解析失败', e, event.data); } }; es.onerror=()=>{ if(!closed){ es.close(); } clearInterval(watchdog); if(!hasContent && !retried){ retried=true; statusEl.textContent='连接异常，重试中…'; setTimeout(()=>{ streamWithEventSource(model, message, aiMsg); }, 300); } else { statusEl.textContent= hasContent ? '连接已关闭' : '连接异常，已关闭'; document.getElementById('btnStop').disabled=true; currentStream={ type:null, es:null, abortController:null }; } } }
    function streamWithEventSourceRag(model, ragTag, message, aiMsg){ const url=new URL(API_STREAM_RAG_GET); url.searchParams.set('model', model); url.searchParams.set('ragTag', ragTag); url.searchParams.set('message', message); const es=new EventSource(url.toString()); currentStream = { type:'sse-rag', es, abortController:null }; let closed=false; let lastUpdate=Date.now(); let hasContent=false; let retried=false; const statusEl=document.getElementById('streamStatus'); statusEl.textContent='RAG 流式连接中…'; const watchdog=setInterval(()=>{ if(Date.now()-lastUpdate>15000 && !closed){ es.close(); closed=true; clearInterval(watchdog); statusEl.textContent='已完成'; document.getElementById('btnStop').disabled=true; currentStream={ type:null, es:null, abortController:null }; } }, 1000); es.onmessage=(event)=>{ try{ const data=JSON.parse(event.data); const content=extractContent(data); const finished=isFinished(data); if(content){ hasContent=true; appendSanitized(aiMsg, content); saveState(); renderMessages(); lastUpdate=Date.now(); } if(finished){ es.close(); closed=true; clearInterval(watchdog); statusEl.textContent='已完成'; document.getElementById('btnStop').disabled=true; currentStream={ type:null, es:null, abortController:null }; } }catch(e){ console.warn('RAG SSE 数据解析失败', e, event.data); } }; es.onerror=()=>{ if(!closed){ es.close(); } clearInterval(watchdog); if(!hasContent && !retried){ retried=true; statusEl.textContent='RAG 连接异常，重试中…'; setTimeout(()=>{ streamWithEventSourceRag(model, ragTag, message, aiMsg); }, 300); } else { statusEl.textContent= hasContent ? 'RAG 连接已关闭' : 'RAG 连接异常，回退到 POST 流式'; if(!hasContent){ streamWithPostRag(model, ragTag, message, aiMsg); } else { document.getElementById('btnStop').disabled=true; currentStream={ type:null, es:null, abortController:null }; } } } }
    async function streamWithPostRag(model, ragTag, message, aiMsg){ const statusEl=document.getElementById('streamStatus'); const abortController=new AbortController(); currentStream = { type:'post-rag', es:null, abortController }; try{ const resp=await fetch(API_STREAM_RAG_POST,{ method:'POST', mode:'cors', cache:'no-store', headers:{ 'Content-Type':'application/json', 'Accept':'text/event-stream, application/stream+json, application/json' }, body: JSON.stringify({ model, ragTag, message }), signal: abortController.signal }); if(!resp.ok){ const bodyText=await resp.text().catch(()=>'' ); console.error('POST 流式请求非200', resp.status, resp.statusText, bodyText); throw new Error('请求失败或不支持流式'); } if(!resp.body){ console.error('响应不支持ReadableStream，Content-Type:', resp.headers.get('Content-Type')); throw new Error('请求失败或不支持流式'); } const reader=resp.body.getReader(); const decoder=new TextDecoder('utf-8'); let buffer=''; while(true){ const { value, done } = await reader.read(); if(done) break; buffer+=decoder.decode(value,{ stream:true }); const lines=buffer.split(/\r?\n/); buffer=lines.pop(); for(const line of lines){ const payload=line.startsWith('data:')?line.slice(5).trim():line.trim(); if(!payload) continue; try{ const data=JSON.parse(payload); const content=extractContent(data); const finished=isFinished(data); if(content){ appendSanitized(aiMsg, content); saveState(); renderMessages(); } if(finished){ buffer=''; document.getElementById('btnStop').disabled=true; currentStream={ type:null, es:null, abortController:null }; statusEl.textContent='已完成'; return; } }catch(e){ } } } }catch(e){ if(e.name==='AbortError'){ statusEl.textContent='已停止'; } else { console.error('POST 流式请求失败', e); const notice=`RAG 流式失败：${e?.message||e}. 将使用普通流式回答。`; appendSanitized(aiMsg, `\n\n${notice}\n`); saveState(); renderMessages(); streamWithEventSource(model, message, aiMsg); } } finally { document.getElementById('btnStop').disabled=true; }
    }
    function extractContent(data){ try{ if(!data) return ''; let c=''; if(data.result&&data.result.output){ c=data.result.output.content||''; } else if(Array.isArray(data.results)&&data.results.length>0){ c=data.results[0]?.output?.content||''; } else { c=''; } // 过滤掉 <think> 标签及其内容，以及首事件提示
      c = c.replace(/<think>[\s\S]*?<\/think>/g, '');
      // 去除包含 RAG 首事件提示的内容
      if(/^【RAG】/.test(c.trim())) return '';
      c = c.replace(/【RAG】[\s\S]*?(\n|$)/g, '');
      return c; }catch{ return ''; } }
    function isFinished(data){ try{ const finish=data?.result?.metadata?.finishReason||data?.results?.[0]?.metadata?.finishReason; return finish==='STOP'; }catch{ return false; } }
    async function loadKnowledgeTags(){ try{ const resp=await fetch(API_RAG_TAGS); const json=await resp.json(); const data=Array.isArray(json?.data)?json.data:[]; const sel=document.getElementById('kbSelect'); sel.querySelectorAll('option:not(:first-child)').forEach(o=>o.remove()); data.forEach(tag=>{ const opt=document.createElement('option'); opt.value=tag; opt.textContent=tag; sel.appendChild(opt); }); }catch(e){ console.warn('知识库列表加载失败', e); } }
    loadState(); renderAll(); loadKnowledgeTags();
  </script>
</body>
</html>
